iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 3

Vue 走過路過不要錯過 Day03 -為什麼改資料畫面就會動?從 JS 的 Proxy 說起

  • 分享至 

  • xImage
  •  

寫 Vue 的時候,我們很習慣這樣做:

state.count++

改完資料,畫面上的數字就跟著變了。我們沒有呼叫任何「更新畫面」的函式,Vue 卻知道資料被改了。

它到底是怎麼知道的?

這個問題的答案,其實不在 Vue 裡,而在 JavaScript 本身。今天先把 Vue 放一邊,來認識 Vue 3 響應式系統的地基:Proxy


先想一個問題:怎麼「偷聽」一個物件?

假設有一個普通物件:

const user = { name: '小明', age: 18 }

需求是這樣的:每次有人讀取或修改 user 的屬性,就要印出一行 log。

條件是不能要求大家改寫成 user.getName() 這種函式呼叫,就是要照常寫 user.name

你會怎麼做?可以先停下來想一下。

以前的做法:Object.defineProperty

在 Proxy 出現之前,比較常見的做法是用 Object.defineProperty,替屬性裝上 getter 和 setter:

const user = { name: '小明', age: 18 }
let _name = user.name

Object.defineProperty(user, 'name', {
  get() {
    console.log('讀取 name')
    return _name
  },
  set(value) {
    console.log('修改 name')
    _name = value
  }
})

user.name          // 讀取 name
user.name = '小華' // 修改 name

能用,但仔細想想會發現幾個麻煩:

  1. 要一個屬性一個屬性包。 age 也要監聽的話,就要再寫一次。
  2. 之後新增的屬性聽不到。 user.hobby = '畫畫' 完全不會觸發,因為當初沒有包到它。
  3. 有些操作根本攔不到。 像是 delete user.name'name' in user

問題的根源在於:這個做法是「改造物件上的每個屬性」。

那如果換個思路,不去改物件,而是在整個物件前面放一個守門人呢?

這就是 Proxy。


Proxy 的基本語法

const proxy = new Proxy(target, handler)

只需要兩樣東西:

名詞 意思
target 原本的物件,真正存放資料的地方
handler 一個物件,裡面放各種攔截規則
trap handler 裡的每個方法,例如 getset,名稱是固定的

可以把它想成:target 是倉庫,proxy 是站在倉庫門口的守門人,handler 是守門人手上的規則手冊。

先看一個什麼規則都沒有的例子:

const user = { name: '小明' }
const p = new Proxy(user, {})

p.name = '小華'
console.log(user.name) // '小華'

handler 是空的時候,所有操作都會直接轉交給 target,就像守門人什麼都不管,全部放行。

這裡有一個很重要的觀念:要透過 proxy 操作,才會被攔截。 如果你直接改 user,等於從後門進倉庫,守門人完全不知道。


get:讀取的時候攔下來

一般物件讀取不存在的屬性,會默默拿到 undefined。打錯字的時候很難發現:

user.nmae // undefined,不會報錯

如果我們想在讀到不存在的屬性時給個提示,可以用 get trap:

const user = { name: '小明', age: 18 }

const p = new Proxy(user, {
  get(target, key) {
    if (key in target) {
      return target[key]
    }
    return `沒有「${String(key)}」這個屬性`
  }
})

p.name  // '小明'
p.nmae  // '沒有「nmae」這個屬性'

每次透過 p 讀屬性,JS 都會呼叫 get,並把「原物件」和「屬性名稱」傳進來。回傳什麼,讀取的人就拿到什麼。

有一個小細節值得記住:key 永遠是字串或 Symbol。 就算你寫 p[0],trap 收到的 key 也是字串 '0'


set:寫入之前先檢查

接著來做資料驗證。規則是:age 必須是 0 到 150 的整數。

const p = new Proxy(user, {
  set(target, key, value) {
    if (key === 'age') {
      if (!Number.isInteger(value) || value < 0 || value > 150) {
        throw new TypeError('age 要是 0~150 的整數')
      }
    }
    target[key] = value
    return true
  }
})

p.age = 20   // 成功
p.age = -5   // TypeError: age 要是 0~150 的整數
p.age = 18.5 // TypeError: age 要是 0~150 的整數

有兩行很容易漏掉:

  • target[key] = value:驗證通過後,要自己把值存進去。忘了寫,資料就不會被寫入。
  • return true:告訴 JS 寫入成功。在嚴格模式下(ES module 和 class 裡預設就是),set 回傳 falsy 值會直接丟出 TypeError。

參數名稱為什麼每篇教學都不一樣?

看其他資料時,你可能會發現有人寫 get(target, key),有人寫 get(target, prop),MDN 則寫 get(target, property, receiver)

其實參數名稱是自己取的,JS 只看順序

trap 第 1 個 第 2 個 第 3 個 第 4 個
get 原物件 屬性名稱 receiver
set 原物件 屬性名稱 要寫入的值 receiver

大家常用 keyvalue,只是因為物件本來就是 key: value 的結構,讀起來比較直覺。

那麼第三、第四個參數的 receiver 是什麼?這就要談到 Proxy 的好搭檔。


Reflect:為什麼不直接寫 target[key]?

先看一個有 getter 的物件:

const parent = {
  first: '爸爸',
  get title() {
    return this.first + '的物件'
  }
}

現在把它包成 Proxy,並讓另一個物件 child 繼承這個 Proxy:

const p = new Proxy(parent, {
  get(target, key) {
    return target[key]
  }
})

const child = Object.create(p)
child.first = '小孩'

child.title // ?

child.title 會是什麼?

答案是 '爸爸的物件'

因為 target[key] 是直接對 target(也就是 parent)讀取,getter 裡的 this 就被固定成 parent,而不是真正發起讀取的 child。

這時候就輪到 receiver 上場,它代表「真正發起這次操作的物件」。搭配 Reflect.get,就能把正確的 this 傳下去:

const p = new Proxy(parent, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver)
  }
})

const child = Object.create(p)
child.first = '小孩'

child.title // '小孩的物件'

Reflect 是 JS 內建的物件,每個 trap 都有一個同名的 Reflect 方法,參數也一模一樣。它們做的事情就是「這個操作原本的預設行為」。

所以前面的 set 可以改寫得更乾淨:

set(target, key, value, receiver) {
  if (key === 'age' && (!Number.isInteger(value) || value < 0 || value > 150)) {
    throw new TypeError('age 要是 0~150 的整數')
  }
  return Reflect.set(target, key, value, receiver)
}

Reflect.set 會幫你存值,也會回傳正確的 true 或 false,一行解決前面容易漏掉的兩件事。

寫 Proxy 的好習慣:只攔你在意的部分,其他原封不動交給 Reflect。


不只 get 和 set

前面提到 Object.defineProperty 攔不到 deletein,Proxy 則都有對應的 trap:

trap 被什麼觸發
get obj.xobj[x]
set obj.x = 1
has 'x' in obj
deleteProperty delete obj.x
ownKeys Object.keys(obj)for...in
apply target 是函式時,呼叫 fn()

而且因為守門人管的是整個物件,之後才新增的屬性一樣會被攔截。前面 defineProperty 的三個麻煩,Proxy 全部解決了。


回到 Vue:自己做一個迷你響應式

終於可以回答開頭的問題了。

「改資料,畫面就更新」拆開來,其實是兩件事:

  1. 讀取的時候,記下「誰用到了這個資料」
  2. 修改的時候,通知「用到這個資料的人」重新執行

讀取和修改,剛好就是 getset 能攔到的時機。我們來寫一個非常簡化的版本:

let activeEffect = null
const deps = new Map()

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      // 1. 讀取時:記下是誰在用這個 key
      if (activeEffect) {
        if (!deps.has(key)) deps.set(key, new Set())
        deps.get(key).add(activeEffect)
      }
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)
      // 2. 修改時:通知用到這個 key 的函式重新執行
      deps.get(key)?.forEach(fn => fn())
      return result
    }
  })
}

function effect(fn) {
  activeEffect = fn
  fn()
  activeEffect = null
}

實際用用看:

<p id="count"></p>
<button id="btn">+1</button>
const state = reactive({ count: 0 })

effect(() => {
  document.querySelector('#count').textContent = state.count
})

document.querySelector('#btn').addEventListener('click', () => {
  state.count++
})

流程是這樣的:

  1. effect 先執行一次更新畫面的函式,函式裡讀了 state.count
  2. 讀取觸發 get,把這個函式記在 count 底下
  3. 按下按鈕,state.count++ 觸發 set
  4. set 找出所有用到 count 的函式,重新執行,畫面就更新了

整個過程中,我們沒有手動呼叫任何更新畫面的程式,只是改了資料。

當然這個版本非常陽春,只能處理單一物件、沒有處理巢狀物件,也沒有批次更新。但 Vue 3 的 reactive() 核心概念就是這樣:用 get 收集依賴,用 set 觸發更新。


小結

  • Object.defineProperty 是改造單一屬性,Proxy 是在整個物件前面放守門人
  • new Proxy(target, handler),handler 裡的 trap 決定要攔哪些操作
  • trap 的參數名稱可以自己取,JS 只看順序
  • trap 裡的預設行為交給同名的 Reflect 方法,並記得傳 receiver
  • Vue 3 的響應式,本質上就是「get 時記錄、set 時通知」

明天 Day 4 會正式進入 Vue:既然有了 reactive,為什麼 Vue 還需要 ref



上一篇
Vue 走過路過不要錯過 Day02 - Vue 可以只是一個 script 標籤:從 CDN 到 Vite,看懂 .vue 檔背後的轉換
下一篇
Vue 走過路過不要錯過 Day04 - 有了 reactive,為什麼還要 ref?從 .value 說起
系列文
重新認識Vue 走過路過不要錯過9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言